'''
base class for hwdef processing

AP_FLAKE8_CLEAN
'''

from __future__ import annotations

import os
import re
import shlex
import sys

from dataclasses import dataclass


class IncludeNotFoundException(Exception):
    def __init__(self, hwdef, includer):
        self.hwdef = hwdef
        self.includer = includer


@dataclass
class SPIDev():
    name : str


@dataclass
class I2CDev():
    busnum : int
    busaddr : int
    buses_to_probe : str


@dataclass
class Baro():
    driver : str
    probe : str
    devlist : list


@dataclass(frozen=True)
class Compass():
    driver: str
    probe: str
    devlist: list
    force_external: str  # should be bool eventually
    rotation: str


class HWDef:
    def __init__(self, quiet=False, outdir=None, hwdef: list | None = None):
        if hwdef is None:
            hwdef = []

        self.outdir = outdir
        self.hwdef = hwdef
        self.quiet = quiet

        # dictionary of all config lines, indexed by first word
        self.config = {}

        # all config lines in order
        self.alllines = []

        # allow for extra env vars
        self.env_vars = {}

        # output lines:
        self.all_lines = []

        # integer defines
        self.intdefines = {}

        # boolean indicating whether we have read and processed self.hwdef
        self.processed_hwdefs = False
        # a set of files which have been processed to populate this object:
        self.loaded_files = set()
        # a set of files which have been generated by this object:
        self.output_files = set()

        # sensor lists
        self.imu_list = []
        self.compass_list = []
        self.baro_list = []

        # populate a stale defines map from define name to reason-it-is-bad
        self.stale_defines = self.get_stale_defines()

        # members used to ensure we don't have duplicate sensors defined:
        self.seen_BARO_lines = set()
        self.seen_COMPASS_lines = set()

    def is_int(self, str):
        '''check if a string is an integer'''
        try:
            int(str)
        except Exception:
            return False
        return True

    def error(self, str):
        '''show an error and exit'''
        print("Error: " + str)
        sys.exit(1)

    def load_file_with_include(self, fname):
        '''load a file as an array of lines, processing any include lines'''
        lines = open(fname, 'r').readlines()
        ret = []
        for line in lines:
            if line.startswith("include"):
                a = shlex.split(line)
                if len(a) > 1 and a[0] == "include":
                    fname2 = os.path.relpath(os.path.join(os.path.dirname(fname), a[1]))
                    ret.extend(self.load_file_with_include(fname2))
                    continue
            ret.append(line)
        return ret

    def uses_filepath(self, filepath : str) -> bool:
        return os.path.abspath(filepath) in self.loaded_files

    def get_output_path(self, relpath):
        # keep track of files we've generated. input path is relative to output.
        self.output_files.add(relpath)
        return os.path.join(self.outdir, relpath)

    def running_in_CI(self):
        return os.getenv("GITHUB_ACTIONS") == "true"

    def get_numeric_board_id(self):
        '''return a numeric board ID, which may require mapping a string to a
        number via board_list.txt'''
        some_id = self.get_config('APJ_BOARD_ID')
        if some_id.isnumeric():
            if self.running_in_CI():
                raise ValueError(f"Numeric board ID found ({some_id}).  Your APJ_BOARD_ID must not use a number.  Change the number to be the name of the board used in Tools/AP_Bootloader/board_types.txt")  # noqa:E501
            return some_id

        board_types_filename = "board_types.txt"
        topdir = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../../../..')
        board_types_dirpath = os.path.join(topdir, "Tools", "AP_Bootloader")
        board_types_filepath = os.path.join(board_types_dirpath, board_types_filename)
        for line in open(board_types_filepath, 'r'):
            m = re.match(r"(?P<name>[-\w]+)\s+(?P<board_id>\d+)", line)
            if m is None:
                continue
            if m.group('name') == some_id:
                return m.group('board_id')

        raise ValueError("Unable to map (%s) to a board ID using %s" %
                         (some_id, board_types_filepath))

    def write_all_lines(self, hwdat):
        f = open(hwdat, 'w')
        f.write('\n'.join(self.all_lines))
        f.close()

    def write_defaulting_define(self, f, name, value):
        f.write(f"#ifndef {name}\n")
        f.write(f"#define {name} {value}\n")
        f.write("#endif\n")

    def write_define(self, f, name, value):
        f.write(f"#define {name} {value}\n")

    def write_hwdef_header(self, outfilename):
        '''write hwdef header file'''
        self.progress("Writing hwdef setup in %s" % outfilename)
        f = open(outfilename, 'w')

        f.write('''/*
 generated hardware definitions from hwdef.dat - DO NOT EDIT
*/

#pragma once

#ifndef TRUE
#define TRUE 1
#endif

#ifndef FALSE
#define FALSE 0
#endif

''')

        self.write_hwdef_header_content(f)

        f.close()

    def progress(self, message):
        if self.quiet:
            return
        print(message)

    def process_file(self, filename, depth=0):
        '''process a hwdef.dat file'''
        self.progress(f"Processing {filename}")
        self.loaded_files.add(os.path.abspath(filename))
        try:
            f = open(filename, "r")
        except Exception as e:
            if False:
                raise e
            self.error("Unable to open file %s" % filename)
        for line in f.readlines():
            line = line.split('#')[0] # ensure we discard the comments
            line = line.strip()
            if len(line) == 0 or line[0] == '#':
                continue
            a = shlex.split(line)
            if a[0] == "include" and len(a) > 1:
                include_file = a[1]
                if include_file[0] != '/':
                    dir = os.path.dirname(filename)
                    include_file = os.path.normpath(
                        os.path.join(dir, include_file))
                self.process_file(include_file, depth+1)
            else:
                self.process_line(line, depth)

    def process_hwdefs(self):
        for fname in self.hwdef:
            self.process_file(fname)
        self.processed_hwdefs = True

    def run(self):
        # process input file
        self.process_hwdefs()

        # write out hwdef.h
        self.write_hwdef_header(self.get_output_path("hwdef.h"))

    def process_line(self, line, depth):
        '''process one line of pin definition file'''
        a = shlex.split(line, posix=False)

        if a[0] == 'undef':
            return self.process_line_undef(line, depth, a)

        if a[0] == 'env':
            return self.process_line_env(line, depth, a)

        if a[0] == 'define':
            return self.process_line_define(line, depth, a)

        elif a[0] == 'IMU':
            self.imu_list.append(a[1:])

        elif a[0] == 'COMPASS':
            self.process_line_COMPASS(line, depth, a)

        elif a[0] == 'BARO':
            self.process_line_BARO(line, depth, a)

    def process_line_undef(self, line, depth, a):
        for u in a[1:]:
            self.progress("Removing %s" % u)
            self.config.pop(u, '')
            self.intdefines.pop(u, '')
            # also remove all occurrences of defines in previous lines if any
            for line in self.alllines[:]:
                if line.startswith('define') and u == line.split()[1]:
                    self.alllines.remove(line)
            if u == 'IMU':
                self.imu_list = []
            if u == 'COMPASS':
                self.compass_list = []
                self.seen_COMPASS_lines = set()
            if u == 'BARO':
                self.baro_list = []
                self.seen_BARO_lines = set()

    def process_line_env(self, line, depth, a):
        self.progress("Adding environment %s" % ' '.join(a[1:]))
        if len(a[1:]) < 2:
            self.error("Bad env line for %s" % a[0])
        name = a[1]
        value = ' '.join(a[2:])
        self.env_vars[name] = value

    def get_stale_defines(self):
        '''returns a map with a stale define and a comment as to what to do about it'''
        return {
            'HAL_COMPASS_DEFAULT': 'HAL_COMPASS_DEFAULT is no longer used; remove it from your hwdef',
            'HAL_NO_GCS': 'HAL_NO_GCS is no longer used; try "define HAL_GCS_ENABLED 0"',
            'HAL_NO_LOGGING': 'HAL_NO_LOGGING is no longer used; try "define HAL_LOGGING_ENABLED 0"',
            'HAL_NO_UARTDRIVER': 'HAL_NO_UARTDRIVER is no longer used; try "define AP_HAL_UARTDRIVER_ENABLED 0"',
            'HAL_DISABLE_LOOP_DELAY': 'HAL_DISABLE_LOOP_DELAY is no longer used; try "define HAL_SCHEDULER_LOOP_DELAY_ENABLED 0"',  # noqa:E501
            'HAL_EXTERNAL_AHRS_ENABLED': 'HAL_EXTERNAL_AHRS_ENABLED is no longer used; try "define AP_EXTERNAL_AHRS_ENABLED 1"',  # noqa:E501
            'HAL_PROBE_EXTERNAL_I2C_COMPASSES': 'HAL_PROBE_EXTERNAL_I2C_COMPASSES is no longer used; try "define AP_COMPASS_PROBING_ENABLED 1"',  # noqa:E501
            'HAL_SKIP_AUTO_INTERNAL_I2C_PROBE': 'HAL_SKIP_AUTO_INTERNAL_I2C_PROBE is no longer used; try "define AP_COMPASS_INTERNAL_BUS_PROBING_ENABLED 0',  # noqa:E501
            'HAL_COMPASS_DISABLE_IST8310_INTERNAL_PROBE': 'HAL_COMPASS_DISABLE_IST8310_INTERNAL_PROBE is no longer used; try "define AP_COMPASS_IST8310_INTERNAL_BUS_PROBING_ENABLED 0"',  # noqa:E501
            'BOARD_PWM_COUNT_DEFAULT': 'BOARD_PWM_COUNT_DEFAULT is no longer used; remove it from your hwdef files',
        }

    def assert_good_define(self, name):
        if name in self.stale_defines:
            self.error(self.stale_defines[name])

    def process_line_define(self, line, depth, a):
        # defines are currently written out a little haphazardly, but
        # we do the sanity checks here:
        result = re.match(r'define\s*([A-Z_0-9]+)\s*', line)
        if result is not None:
            # ensure that stale defines aren't introduced into hwdef files:
            name = result.group(1)
            self.assert_good_define(name)

        # extract numerical defines for processing by other parts of the script
        result = re.match(r'define\s*([A-Z_0-9]+)\s+([0-9]+)', line)
        if result:
            (name, intvalue) = (result.group(1), int(result.group(2)))
            if name in self.intdefines and self.intdefines[name] == intvalue:
                msg = f"{name} already in defines with same value"
                if depth == 0:
                    print(msg)
                    # raise ValueError(msg)

            self.intdefines[name] = intvalue

    def parse_spi_device(self, dev):
        '''parse a SPI:xxx device item'''
        o = self.spi_dev_to_object(dev)
        return f'hal.spi->get_device("{o.name}")'

    def spi_dev_to_object(self, dev):
        a = dev.split(':')
        if len(a) != 2:
            self.error("Bad SPI device: %s" % dev)
        return SPIDev(a[1])

    def i2c_dev_to_object(self, dev):
        '''parse a I2C:xxx:xxx device item'''
        a = dev.split(':')
        if len(a) != 3:
            self.error("Bad I2C device: %s" % dev)
        busaddr = int(a[2], base=0)

        busnum = a[1]
        buses_to_probe = 'SINGLE'
        if busnum in {'ALL_INTERNAL', 'ALL', 'ALL_EXTERNAL'}:
            # ... not a bus number at all
            buses_to_probe = busnum
            busnum = None
        else:
            busnum = int(busnum)

        return I2CDev(
            busaddr=busaddr,
            busnum=busnum,
            buses_to_probe=buses_to_probe,
        )

    def parse_i2c_device(self, dev):
        '''parse a I2C:xxx:xxx device item'''
        o = self.i2c_dev_to_object(dev)
        a = ['x', o.buses_to_probe]
        busaddr = o.busaddr

        if a[1] == 'ALL_EXTERNAL':
            return ('FOREACH_I2C_EXTERNAL(b)', 'GET_I2C_DEVICE(b,0x%02x)' % (busaddr))
        elif a[1] == 'ALL_INTERNAL':
            return ('FOREACH_I2C_INTERNAL(b)', 'GET_I2C_DEVICE(b,0x%02x)' % (busaddr))
        elif a[1] == 'ALL':
            return ('FOREACH_I2C(b)', 'GET_I2C_DEVICE(b,0x%02x)' % (busaddr))
        elif a[1] != 'SINGLE':
            raise ValueError(f"Unknown buses_to_probe type {o.buses_to_probe=}")
        busnum = o.busnum
        return ('', 'GET_I2C_DEVICE(%u,0x%02x)' % (busnum, busaddr))

    def seen_str(self, dev):
        '''return string representation of device for checking for duplicates'''
        ret = dev[:2]
        if dev[-1].startswith("BOARD_MATCH("):
            ret.append(dev[-1])
        return str(ret)

    def write_IMU_config(self, f):
        '''write IMU config defines'''
        devlist = []
        wrapper = ''
        seen = set()
        for dev in self.imu_list:
            if self.seen_str(dev) in seen:
                self.error("Duplicate IMU: %s" % self.seen_str(dev))
            seen.add(self.seen_str(dev))
            driver = dev[0]
            # get instance number if mentioned
            instance = -1
            aux_devid = -1
            if dev[-1].startswith("INSTANCE:"):
                instance = int(dev[-1][9:])
                dev = dev[:-1]
            if dev[-1].startswith("AUX:"):
                aux_devid = int(dev[-1][4:])
                dev = dev[:-1]
            for i in range(1, len(dev)):
                if dev[i].startswith("SPI:"):
                    dev[i] = self.parse_spi_device(dev[i])
                elif dev[i].startswith("I2C:"):
                    (wrapper, dev[i]) = self.parse_i2c_device(dev[i])
            n = len(devlist)+1
            devlist.append('HAL_INS_PROBE%u' % n)
            if aux_devid != -1:
                f.write('#define HAL_INS_PROBE%u %s ADD_BACKEND_AUX(AP_InertialSensor_%s::probe(*this,%s),%d)\n' %
                        (n, wrapper, driver, ','.join(dev[1:]), aux_devid))
            elif instance != -1:
                f.write('#define HAL_INS_PROBE%u %s ADD_BACKEND_INSTANCE(AP_InertialSensor_%s::probe(*this,%s),%d)\n' %
                        (n, wrapper, driver, ','.join(dev[1:]), instance))
            elif dev[-1].startswith("BOARD_MATCH("):
                f.write(
                    '#define HAL_INS_PROBE%u %s ADD_BACKEND_BOARD_MATCH(%s, AP_InertialSensor_%s::probe(*this,%s))\n'
                    % (n, wrapper, dev[-1], driver, ','.join(dev[1:-1])))
            else:
                f.write(
                    '#define HAL_INS_PROBE%u %s ADD_BACKEND(AP_InertialSensor_%s::probe(*this,%s))\n'
                    % (n, wrapper, driver, ','.join(dev[1:])))
        if len(devlist) > 0:
            if len(devlist) < 3:
                self.write_defaulting_define(f, 'INS_MAX_INSTANCES', len(devlist))
            f.write('#define HAL_INS_PROBE_LIST %s\n\n' % ';'.join(devlist))

    def process_line_COMPASS(self, line, depth, a):
        orig_a = a
        if True:
            dev = a[1:]
            seen = self.seen_COMPASS_lines

            if self.seen_str(dev) in seen:
                self.error("Duplicate MAG: %s" % self.seen_str(dev))
            seen.add(self.seen_str(dev))
            driver = dev[0]
            dev = dev[1:]
            probe = 'probe'
            a = driver.split(':')
            driver = a[0]

            if len(a) > 1 and a[1].startswith('probe'):
                probe = a[1]

            expected_device_count = 1
            if driver == 'AK09916' and probe == 'probe_ICM20948':
                expected_device_count = 1
            elif driver == 'AK09916' and probe != 'probe':
                expected_device_count = 0
            elif driver == 'AK8963' and probe == 'probe_mpu9250':
                expected_device_count = 1
            elif driver == 'AK8963' and probe != 'probe':
                expected_device_count = 0

            devlist = []
            for i in range(0, expected_device_count):
                d = dev[0]
                dev = dev[1:]
                if d.startswith("SPI:"):
                    devlist.append(self.spi_dev_to_object(d))
                elif d.startswith("I2C:"):
                    devlist.append(self.i2c_dev_to_object(d))
                elif ((driver == 'AK8963' and probe == 'probe_mpu9250') or
                      (driver == 'AK09916' and probe == 'probe_ICM20948') and
                      d.isdigit()):
                    devlist.append(d)
                else:
                    raise ValueError(f"Unknown device ({d=}) ({orig_a=})")

            # some compass backends take force-external, some don't:
            if len(dev) == 2:
                (force_external, rotation) = dev
            elif len(dev) == 1:
                force_external = None
                (rotation,) = dev
            else:
                raise ValueError(f"Pieces left over from ({orig_a=}): ({dev=})")

            c = Compass(
                driver=driver,
                probe=probe,
                devlist=devlist,
                force_external=force_external,
                rotation=rotation,
            )
            self.compass_list.append(c)

    def write_MAG_config(self, f):
        '''write MAG config defines'''
        devlist = []
        for compass in self.compass_list:
            driver = compass.driver
            probe = compass.probe
            wrapper = ''
            if len(compass.devlist) > 0:
                if isinstance(compass.devlist[0], I2CDev):
                    buses_to_probe = compass.devlist[0].buses_to_probe
                    if buses_to_probe == 'ALL':
                        wrapper = 'FOREACH_I2C(b)'
                    elif buses_to_probe == 'ALL_EXTERNAL':
                        wrapper = 'FOREACH_I2C_EXTERNAL(b)'
                    elif buses_to_probe == 'ALL_INTERNAL':
                        wrapper = 'FOREACH_I2C_INTERNAL(b)'
                    elif buses_to_probe == 'SINGLE':
                        pass
                    elif isinstance(buses_to_probe, int):
                        pass
                    else:
                        raise ValueError(f"Unexpected buses_to_probe {buses_to_probe=}")

            n = len(devlist)+1
            devlist.append('HAL_MAG_PROBE%u' % n)

            args = []
            for dev in compass.devlist:
                if isinstance(dev, I2CDev):
                    if dev.buses_to_probe == 'SINGLE':
                        busnum = str(dev.busnum)
                    elif dev.buses_to_probe in {'ALL', 'ALL_EXTERNAL', 'ALL_INTERNAL'}:
                        busnum = 'b'
                    else:
                        raise ValueError("Unexpected {buses_to_probe=}")
                    args.extend(["GET_I2C_DEVICE(%s,0x%02x)" % (busnum, dev.busaddr)])
                elif isinstance(dev, SPIDev):
                    args.extend([f'hal.spi->get_device("{dev.name}")'])
                elif dev.isdigit():
                    args.extend([str(dev)])
                else:
                    raise ValueError("unexpected devbit {dev=}")

            if compass.force_external is not None:
                args.append(str(compass.force_external))
            args.append(str(compass.rotation))

            f.write(
                '#define HAL_MAG_PROBE%u %s {add_backend(DRIVER_%s, AP_Compass_%s::%s(%s));RETURN_IF_NO_SPACE;}\n'
                % (n, wrapper, driver, driver, probe, ','.join(args)))
            f.write(f"#undef AP_COMPASS_{driver}_ENABLED\n#define AP_COMPASS_{driver}_ENABLED 1\n")
        if len(devlist) > 0:
            f.write('#define HAL_MAG_PROBE_LIST %s\n\n' % ';'.join(devlist))

    def process_line_BARO(self, line, depth, a):
        if True:
            dev = a[1:]
            seen = self.seen_BARO_lines

            if self.seen_str(dev) in seen:
                self.error("Duplicate BARO: %s" % self.seen_str(dev))
            seen.add(self.seen_str(dev))
            driver = dev[0]
            probe = 'probe'
            a = driver.split(':')
            driver = a[0]
            if len(a) > 1 and a[1].startswith('probe'):
                probe = a[1]
            devlist = []
            for d in dev[1:]:
                if d.startswith("SPI:"):
                    devlist.append(self.spi_dev_to_object(d))
                elif d.startswith("I2C:"):
                    devlist.append(self.i2c_dev_to_object(d))
                elif re.match("0x[0-9A-F][0-9A-F]", d.lower()):
                    # LPS2XH uses a single address to identify the IMU
                    # which the baro is attached to...
                    devlist.append(d)
                else:
                    raise ValueError(f"Unknown device ({dev=})")

            b = Baro(
                driver=driver,
                probe=probe,
                devlist=devlist,
            )
            self.baro_list.append(b)

    def write_BARO_config(self, f):
        '''write barometer config defines'''
        devlist = []
        for baro in self.baro_list:
            driver = baro.driver
            probe = baro.probe

            args = ['*this']

            n = len(devlist)+1

            args = []

            if driver == "DPS280":
                # special handling for DPS280; use a probe method of
                # the correct signature to pass into probe_spi_dev:
                probe = "probe_280"

            backend_probe_method = f"AP_Baro_{driver}::{probe}"

            if driver == "ICM20789":
                baro_probe_method_name = "probe_icm20789"
            elif driver == "LPS2XH" and probe == "probe_InvensenseIMU":
                baro_probe_method_name = "probe_lps2xh_via_Invensense_IMU"
            elif isinstance(baro.devlist[0], SPIDev):
                baro_probe_method_name = "probe_spi_dev"
            elif isinstance(baro.devlist[0], I2CDev):
                baro_probe_method_name = "probe_i2c_dev"

            args = []
            if baro_probe_method_name == 'probe_spi_dev':
                args.append(backend_probe_method)
                expected_devices = 1
            elif baro_probe_method_name == 'probe_i2c_dev':
                args.append(backend_probe_method)
                expected_devices = 1
            elif baro_probe_method_name == 'probe_icm20789':
                expected_devices = 2
            elif baro_probe_method_name == 'probe_lps2xh_via_Invensense_IMU':
                expected_devices = 2
            else:
                raise ValueError(f"probe method {baro_probe_method_name}")
            if len(baro.devlist) != expected_devices:
                raise ValueError("Expected {len(baro.devlist)} devices for {baro_probe_method_name}")

            for dev in baro.devlist:
                if isinstance(dev, I2CDev):
                    if dev.buses_to_probe != 'SINGLE':
                        # if you need to do more than SINGLE then create methods like
                        # probe_i2c_on_external_buses(probe_fn, dev.busaddr)
                        raise ValueError("Only SINGLE is handled at the moment")
                    args.extend([str(dev.busnum), str(dev.busaddr)])
                elif isinstance(dev, SPIDev):
                    args.extend([f'"{dev.name}"'])
                else:
                    args.extend([str(dev)])

            print(f"{args=}")
            f.write(f'''
#define HAL_BARO_PROBE{n} {baro_probe_method_name}({', '.join(args)}); RETURN_IF_NO_SPACE;
''')

            devlist.append('HAL_BARO_PROBE%u' % n)
            f.write(f"#undef AP_BARO_{driver}_ENABLED\n#define AP_BARO_{driver}_ENABLED 1\n")
        if len(devlist) > 0:
            f.write('#define HAL_BARO_PROBE_LIST %s\n\n' % ';'.join(devlist))

    def write_device_table(self, f, description, define_name, devlist):
        '''writes out a #define which can be used as the body of a C
        structure to populate object constructor arguments'''
        if len(devlist) == 0:
            f.write(f'\n// No {description}\n')
            return

        f.write(f'\n// {description} table\n')
        f.write(f'#define {define_name} \\\n')
        f.write(',\\\n'.join([f"   {x}" for x in devlist]))
        f.write("\n")
